iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
Modern Web

Ash framework, Elixir 的商業邏輯框架系列 第 4 篇

替 Resource 加上欄位

  • 分享至 

  • xImage
  •  

CMS 需要的欄位

目前 Post 只有 title 跟 body
一篇貼文還需要網址用的 slug、摘要、發布狀態跟發布時間
我們一個一個加上去, 順便看每個選項在做什麼

slug

網址會用到不能重複的 slug:

attribute :slug, :string do
  allow_nil? false
  public? true
end
identities do
  identity :unique_slug, [:slug]
end

allow_nil? false 代表必填, create 跟 update 都會檢查
identities 定義哪些欄位組合起來不能重複, 在 Postgres 上會建成 unique index
unique_slug 這個名字之後查詢跟 upsert 也會用到

excerpt

attribute :excerpt, :string do
  public? true
  constraints max_length: 300
end

constraints 是跟著型別走的限制
字串可以設 max_length、min_length、match、trim?、allow_empty?
轉換 input 的時候就會檢查

status

attribute :status, :atom do
  allow_nil? false
  public? true
  default :draft
  constraints one_of: [:draft, :review, :published, :archived]
end

狀態只有固定幾種, 用 :atom 加上 one_of 最簡單, 存進資料庫時是文字
沒給 status 的話會用 default :draft
所以就算設了 allow_nil? false, create 時不傳 status 也沒問題

published_at

attribute :published_at, :utc_datetime do
  public? true
end

:utc_datetime 精確到秒, timestamps() 用的 :utc_datetime_usec 精確到微秒
目前還沒有地方會寫入 published_at, 之後交給 action 處理

public? 跟 accept

四個新欄位都加了 public? true
Day 3 提過 attribute 預設是 private
public? 決定外面看不看得到、能不能碰這個欄位
但一個 action 實際收哪些欄位, 要看它的 accept

先讓預設的 create 跟 update 收 published_at 以外的欄位:

actions do
  defaults [
    :read,
    :destroy,
    create: [:title, :slug, :body, :excerpt, :status],
    update: [:title, :slug, :body, :excerpt, :status]
  ]
end

published_at 是 public, 可是沒有任何 action 收它
等等在 iex 裡會看到差別

migration

改完 resource, 一樣用 ash.codegen 產生 migration:

$ mix ash.codegen add_publishing_attributes
* creating priv/repo/migrations/20260916172819_add_publishing_attributes.exs
* creating priv/resource_snapshots/repo/posts/20260916172820.json
def up do
  alter table(:posts) do
    add :slug, :text, null: false
    add :excerpt, :text
    add :status, :text, null: false, default: "draft"
    add :published_at, :utc_datetime
  end

  create unique_index(:posts, [:slug], name: "posts_unique_slug_index")
end

對照一下:

  • allow_nil? false 變成 null: false
  • default :draft 變成 default: "draft"
  • identity 變成 unique index

max_length 跟 one_of 不會變成資料庫的限制, 由 Ash 自己檢查

slug 不能是 null, 可是 Day 2 建的貼文都沒有 slug
直接 mix ash.migrate 會失敗
dev 環境的資料直接重置就好:

mix ash.reset

正式環境就得先在 migration 裡補上回填 slug 的 SQL, 再跑 migrate

在 iex 裡試試

iex> alias DemoCms.Content.Post
iex> {:ok, post} = Ash.create(Post, %{title: "Hello, Ash!", slug: "hello-ash", body: "First post."})
iex> {post.slug, post.status, post.published_at, post.excerpt}
{"hello-ash", :draft, nil, nil}

status 沒有傳, 用的是預設值

每條規則都試一次:

iex> Ash.create!(Post, %{title: "No slug"})
** (Ash.Error.Invalid)
* attribute slug is required

iex> Ash.create!(Post, %{title: "Again", slug: "hello-ash"})
** (Ash.Error.Invalid)
* Invalid value provided for slug: has already been taken.

iex> Ash.create!(Post, %{title: "x", slug: "x", status: :live})
** (Ash.Error.Invalid)
* Invalid value provided for status: atom must be one of "draft, review, published, archived", got: :live.

iex> Ash.create!(Post, %{title: "x", slug: "x", excerpt: String.duplicate("x", 301)})
** (Ash.Error.Invalid)
* Invalid value provided for excerpt: length must be less than or equal to 300.

slug 重複是 Postgres 的 unique index 擋下來的
Ash 把資料庫的錯誤轉回 slug 欄位上
其他三個在送進資料庫前就擋掉了

input 會先照欄位型別轉換, 所以 atom 欄位傳字串也行:

iex> Ash.create!(Post, %{title: "y", slug: "y", status: "review"}).status
:review

再來看 public 但沒有被 accept 的欄位:

iex> Ash.create!(Post, %{title: "z", slug: "z", published_at: "2026-09-01T09:00:00Z"})
** (Ash.Error.Invalid)
* No such input `published_at` for action DemoCms.Content.Post.create

public? 管的是讀不讀得到、能不能回傳出去
accept 管的是這個 action 收不收
published_at 只符合前者

最後把 resource 的定義跟實際的資料表放在一起看:

iex> Ash.Resource.Info.attributes(Post) |> Enum.map(&{&1.name, &1.type, &1.allow_nil?, &1.default})
[
  {:id, Ash.Type.UUID, false, &Ash.UUID.generate/0},
  {:title, Ash.Type.String, false, nil},
  {:slug, Ash.Type.String, false, nil},
  {:body, Ash.Type.String, true, nil},
  {:excerpt, Ash.Type.String, true, nil},
  {:status, Ash.Type.Atom, false, :draft},
  {:published_at, Ash.Type.UtcDatetime, true, nil},
  {:inserted_at, Ash.Type.UtcDatetimeUsec, false, &DateTime.utc_now/0},
  {:updated_at, Ash.Type.UtcDatetimeUsec, false, &DateTime.utc_now/0}
]

iex> DemoCms.Repo.query!("select column_name, data_type, is_nullable, column_default from information_schema.columns where table_name = 'posts'").rows
[
  ["id", "uuid", "NO", "gen_random_uuid()"],
  ["title", "text", "NO", nil],
  ["body", "text", "YES", nil],
  ["inserted_at", "timestamp without time zone", "NO", "(now() AT TIME ZONE 'utc'::text)"],
  ["updated_at", "timestamp without time zone", "NO", "(now() AT TIME ZONE 'utc'::text)"],
  ["slug", "text", "NO", nil],
  ["excerpt", "text", "YES", nil],
  ["status", "text", "NO", "'draft'::text"],
  ["published_at", "timestamp without time zone", "YES", nil]
]

欄位都準備好了, 但現在誰都能透過預設 action 直接改 status
下一篇把預設的 create 跟 update 換成有名字的 action


上一篇
讀懂產生出來的 Domain 與 Resource
系列文
Ash framework, Elixir 的商業邏輯框架 共 4 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言